iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
在函數式程式設計風格的編碼過程中,我們常常利用各種型別容器(Option、Either、…等型別類別)來放置我們實際要處理的值,再利用map、ap、flatMap將函數進行轉換以完成函數之間的合成或「接管」。有時候,真實世界處理的資料較為複雜,需要頻繁的驗證、錯誤處理和非同步的延遲執行,容器會交錯嵌套讓函數難以合成。這時候我們需要一套機制來進行不同型別容器之間的轉換,我們稱之為自然轉換(Natural Transformation)

自然轉換

  所謂的自然轉換就是型別容器之間的轉換,但是實際保存的值並不改變。其實這是自然而然的事,並不困難,但是我們仍要注意特殊值的處理、補充資料和資料流失的可能性。
如果要將Option型別容器內的資料轉換為陣列只要將some(a)對應到[a]即可,至於none則對應到空陣列[],這樣即可做到一對一的對應,在數學上我們稱之為Isomorphic。Task和IO的轉換、Either和TaskEither、…等的轉換都是Isomorphic,也就是一對一的轉換。有些容器之間的轉換卻無法做到一對一的對應,像Option和Either的轉換就不是一對一的轉換,如果是Either轉換到Option,Either中不同的left都只能對應到到Option中的none,因此會造成資料流失;反過來,每次Option轉換到Either時候則需要補充資料,因此我們需要一個函數將none對應到left。

自然轉換的原則

自然轉換有一個重要的原則,它應該「與映射函數無關」,也就是 先 map 再轉換 = 先轉換再 map;假設nt是Functor F對應到Functor G的自然轉換,mapF是Functor F的map,mapG是Functor G的map,下面的結果是相同的。

pipe(G a, nt, mapG(f)) === pipe(mapF(f), nt) === G b
Natural transformation
圖片來源:https://github.com/MostlyAdequate/mostly-adequate-guide/blob/master/images/natural_transformation.png

fp-ts已經實作了許多的自然轉換函數,佈署在不同的模組之間,例如在Option模組有fromEither就是將Either結構轉換到Option結構,Task模組中的fromIO就是將IO結構轉換到Task結構,依次類推。當不同的模組同時匯出fromIO時,我們可以重新命名為IOToTask,如此可以清楚理解轉換的來源和目的地。

範例

如果有一個應用場景是我們要在資料庫中用使用者id搜尋email,然後驗證這個email是否為一個email字串,最後將這個email在終端機上顯示。如果不考慮非同步執行和錯誤處理,這只一個number -> string -> string -> void的流程,但是現實的情況卻是複雜的多,id可能查詢不到資料,非同步資料庫可能出狀況(資料庫沒有運行),email驗證可能不合格,非同步執行終端機也可能失敗。我們模擬實作這個情境

import { delay } from './lib';
import * as IO from 'fp-ts/IO';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import { pipe, flow } from 'fp-ts/function';
import { log } from 'fp-ts/Console';
import { findFirst } from 'fp-ts/Array'; // 找到傳回some, 沒有找到傳回none

// 資料庫模擬
const users = [
  { id: 1, email: 'alice@example.com' },
  { id: 2, email: 'bob@example.com' },
  { id: 3, email: 'charlie@example.com' },
  { id: 4, email: 'john#example.com' },
];
// 三個主要函數geEmail、validateEmail和logEmail
// GetEmail :: number -> TaskEither Error (Option string)
type GetEmail = (id: number) => TE.TaskEither<Error, O.Option<string>>;
const getEmail: GetEmail = (id) => {
  return tryCatch(
    async () => {
      // 模擬非同步執行
      await delay(3000)();
      const email = pipe(
        users,
        findFirst((u) => u.id === id), // Option<User>
        O.map((u) => u.email) // Option<string>
      );
      const success = Math.random() > 0.2; // 非同步失敗機率為0.2
      if (!success) throw new Error('Failed to load users');
      return email;
    },
    (reason) => (reason instanceof Error ? reason : new Error(String(reason)))
  );
};

// ValidateEmail :: string -> Either Error string
type ValidateEmail = (email: string) => E.Either<Error, string>;
const validateEmail: ValidateEmail = (email): E.Either<Error, string> =>
  email.includes('@')
    ? right(email)
    : left(new Error(`Invalid email: ${email}`));

// LogEmail :: string -> TaskEither Error (IO void)
type LogEmail = (email: string) => TE.TaskEither<Error, IO.IO<void>>;
const logEmail: LogEmail = (email) =>
  tryCatch(
    async () => {
      await delay(2000)();
      // simulate possible error (e.g., reject certain emails)
      const success = Math.random() > 0.1; // 非同步失敗機率為0.1
      if (!success) {
        throw new Error('Failed to log email');
      }
      // simulate logging
      return log(`📧 Logging email: ${email}`);
    },
    (reason) => (reason instanceof Error ? reason : new Error(String(reason)))
  );
type ProcessEmail = (id: number) => T.Task<string>
const processEmailForUser: ProcessEmail = flow(
  getEmail, // number -> TaskEither<Error, Option<string>>
  TE.flatMap(TE.fromOption(() => new Error('User not found'))), // TaskEither<Error, string> 資料補充
  TE.map(validateEmail), // TaskEither<Error, Either<Error, string>>
  TE.flatMap(TE.fromEither), // TaskEither<Error, string>
  TE.flatMap(logEmail), // TaskEither<Error, IO<void>>
  TE.flatMap(TE.fromIO), // TaskEither<Error, void>
  Te.match(
    (e) => e.message,
    () => 'Success email process'
  ), // Task<string>.
  TE.map(log), // Task<IO<string>>
  T.flatMap(T.fromIO) // Task<string>
);

processEmailForUser(1)();
processEmailForUser(2)();
processEmailForUser(3)();
processEmailForUser(4)();
processEmailForUser(99)();
processEmailForUser(-4)();

程式碼解析重點

  1. 主要函數的輸入型別為資料流的實質型別,getEmail -> validateEmail -> logEmail的實質資料流型別為number -> string -> string -> void,所以這三個主要函數的輸入型別分別是number,string和string;依情境分別使用不同型別建構子(F1, F2, F3)建構F1,F2和F3
  2. 將非TaskEither的輸出型別用teFlatMap(自然轉換)解除嵌套,得到TaskEither建構型別。
  3. 最後用teMatch取得錯誤訊息或產生成功訊息,此時的輸出型別已經是Task建構的型別。
  4. 注意最後兩個函數是從Task模組匯出。

今日小結

函數式程式設計在「接管」的過程中,最重要的便是清清楚楚了解輸出入的型別,當「管道」的上一個函數輸出型別與目前函數的輸入不一致時,可利用「自然轉換」讓型別一致。因此「自然轉換」在函數式程式設計風格中佔有很重要的地位,在不同的型別容器可以適時轉換。今日的分享內容就到此為止,明天再見。


上一篇
Day 17. 除錯 - trace & tap
下一篇
Day 19. 函數型別容器 - Reader & State
系列文
數學老師學函數式程式設計 - 以fp-ts啟航20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言